fix(sync): converge channel sidebar state across devices on the same identity - #6525
fix(sync): converge channel sidebar state across devices on the same identity#6525wpfleger96 wants to merge 22 commits into
Conversation
3da6677 to
d0c23c9
Compare
Channel-section sidebar state diverged between a user's devices and sometimes never self-healed. Four client-side gaps fed the divergence: - A local edit that lost whole-blob LWW was silently republished as remote content while the UI kept showing the edit. Now the manager adopts the winning remote head (writes it through to state + storage, advances the watermark) and skips publishing, unifying with the relay's OK-false conflict path as one convergence mechanism. - Edits made inside the 2s publish debounce were dropped on quit or community switch. A durable localStorage outbox persists every edit synchronously and resumes it on next mount; adopt clears the outbox so a superseded edit can never be replayed. - A skewed remote head could push the published createdAt past the relay's future-drift window and wedge all later publishes. createdAt is now clamped inside that window. - Stale-at-open state waited for a reconnect that a healthy socket never fires. A reconciliation loop periodically refetches the head (steady 60s, backoff on failure) and refreshes on window visibility. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Three cross-layer races defeated the one-convergence-mechanism design: - An older in-flight publish unconditionally cleared pending state on completion, erasing a newer edit queued mid-flight. Each pending edit now carries a monotonic generation; a completion clears pending/outbox/retry only via compare-and-swap on the generation it published. - Hook-level remote application (bootstrap/live/periodic) cancelled the pending publish's timers without deciding supersession, stranding the durable outbox and clobbering the optimistic edit. applyRemote now defers entirely to a pending edit, whose own debounced publish converges via publish-or-adopt; the manager's adopt path clears pending before write-through so the winning remote still applies. - The equal-timestamp tie-break kept the largest event id, opposite the relay/database canonical order (created_at DESC, id ASC → lowest id wins). applyRemote now applies a strictly-lower id and ignores ids >= the last applied, so the UI converges on the event the relay actually stored. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
useChannelStars, useChannelMutes, and useChannelSortPreference carried the same inverted equal-timestamp comparator as channel sections: applyRemote kept the largest event id, opposite the relay/database canonical order (created_at DESC, id ASC -> lowest id wins). Two devices writing the same second could leave the UI showing an event the relay did not store. Apply a strictly-lower id and ignore ids >= the last applied, matching the sections fix and the relay winner across all four 30078 sidebar surfaces. Each hook gains a regression test: larger-then-lower id delivery at equal timestamp, lower-id store wins (mutation-checked - reverting >= to <= fails each). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Two convergence holes one layer under the pass-1 fixes: Sections: the pre-publish head check compared the fetched head against the mutable lastRemoteCreatedAt, which a live event observed during the debounce window already advanced to that same head — equality fell through to publish and the local blob overwrote a remote that became head after the edit was queued. Freeze a canonical head baseline (created_at, id) at publishSections and compare the fetched head against that generation baseline instead, adopting when the head advanced. Stars/mutes: applyRemote admits the canonical lower-id winner but then mergeStores resolved equal per-entry updatedAt as local/prev-wins, so a stale larger-id value delivered first survived and undid the winner. Add mergeApplyingRemote which resolves an entry-timestamp tie toward the canonical incoming blob while keeping strictly-newer local entries. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Each prior round patched one cross-generation interleaving and opened another a layer deeper. Kill the race class structurally instead. Sections: serialize publish cycles (one in-flight at a time; a newer edit queued mid-cycle defers and the completion re-drives it). The per-edit pre-publish baseline is frozen at queue time, so a genuine remote observed during the debounce window still adopts, while our own accepted head is folded forward via canonicalMax so a stale generation's own write is never mistaken for a competing remote and adopted away. Dual generation guards in doPublish (post-fetch and pre-publish) stop a stale generation signing or publishing after a newer edit exists. Stars/mutes: scope mergeApplyingRemote (remote-wins on entry-tie) and the pending-publish cancel to fire only on a canonical supersession of an already-applied same-timestamp larger-id head. Every other application (bootstrap/live/newer-timestamp) keeps local-wins mergeStores and does not cancel the pending publish, so a later same-second local click is no longer clobbered by an older remote entry that decrypts late. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…biguous-ACK heads Round-4 client-side convergence fixes for channel-sections/stars/mutes sync, closing two silent edit-loss variants that survived publish serialization. Ambiguous-ACK fold: a publish whose ACK is lost may still have been accepted by the relay. Retain each attempt's signed id; when a later cycle's pre-publish fetch returns a head whose id matches a prior attempt, fold it forward as our own accepted predecessor and publish above it instead of adopting it away and erasing the queued edit. A head the relay never accepted can never surface by id, so the fold is proof-gated on an exact id match. Canonical-supersession dirty overlay (stars + mutes): a lower-id canonical correction that arrives after a same-second local click must not clobber the click. Apply the correction to the prior remote layer, then overlay entries changed locally since that layer; never cancel a pending publish merely because a correction arrived. Client-only: loss discovery relies on the existing pre-publish fetch, live subscription, and reconcile loop rather than a relay conflict signal. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace the LWW register plus ownership/dirty-set/canonical-supersession machinery with a per-entry Lamport `rev` and a single max-merge on every path, mirroring the read-state data model. Each entry carries an additive optional `rev` (missing implies 0; payload stays `version: 1` so older builds keep parsing our blobs). One `mergeStores` orders by updatedAt then rev then the starred/muted-true leaf, and ends in the 500-entry bound. Clicks stamp `updatedAt = max(now, localEntry?.updatedAt ?? 0, maxUpdatedAtSeen(id))` and mint `rev = max(localEntry.rev, maxRevSeen(id)) + 1`, so a click strictly dominates every state its replica has observed and cannot lose to a same-second remote. The sync managers hold a per-channel two-field high-water map fed by a single `observe()` on every ingest path. Stars sync keeps the generation-CAS + single-flight lane and bounded-backoff retry plus a durable outbox so an in-flight publish can never clear a newer pending edit; mutes sync mirrors it. Remote ingestion never touches the pending lane. Deleted: mergeApplyingRemote, mergeStoresWithTie, mergeCanonicalSupersession, dirtyChannelIds, lastAppliedRemoteTs/lastAppliedEventId, and the event-clock branch. Sections and sort are untouched. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… contract The hard-eviction-branch test asserted only the rev tuple outcome on two one-entry stores while its comment claimed the >500-entry eviction/remount setup. Build the real fixture: >500 equal-updatedAt entries so the bound evicts the target by the id tiebreak, then a remounted rev-1 click merged against the retained rev-100 remote, asserting the deterministic rev-100 outcome in both merge orders. Stars and mutes. The unobserved-future mixed-fleet residual was stated but never exercised directly: the fast-clock suites cover only the observed-future fix. Add a click with an empty high-water at t, then a genuinely unobserved opposite-value head at t+300 that wins on the primary updatedAt key. Both hook suites. Test-only; no production source changes. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at exact head 0d797b550e405eea9557e053f884533ad3dd891a. The relay ordering and the per-entry max-merge are coherent, but the lifecycle still has reproducible edit-loss paths.
[P1] The durable outboxes are not safe across desktop windows
Sections, stars, and mutes share one localStorage outbox key per identity + relay, but each window owns only an in-memory generation. A write in one window therefore replaces another window's pending payload, and a completion in either window removes the shared key without proving that it still owns the persisted value.
For sections, window A can start publishing edit A, window B can replace the outbox with newer edit B, and A's ACK then clears B's outbox. If B closes before its debounce fires, the next bootstrap applies relay head A before discovering there is no outbox, so B is permanently lost. See channelSectionsStorage.ts:211-245, channelSectionsSync.ts:262-281,284-313,522, and useChannelSections.ts:138-156.
Stars and mutes can lose independent clicks even earlier: two windows starting from the same store can click different channels before receiving each other's asynchronous storage event. Their whole-store main/outbox writes race, teardown cancels both timers, and remount resumes only the last blob. The storage handler merges only into React state; it does not durably merge the main store or outbox. See channelStarsStorage.ts:206-242, useChannelStars.ts:57-72,95-113, and channelStarsSync.ts:256-260,383-393 (mutes mirror these paths).
Please give persisted attempts cross-window ownership, not only manager-local generations. A per-operation outbox whose owner deletes only its own record, or an actual cross-window serialization mechanism, would close both overwrite and stale-clear races. Add multi-window tests that interleave write, ACK, storage delivery, teardown, and remount.
[P1] Sort preferences still drop pending edits on ordinary lifecycle and failure paths
publishSortPrefs keeps intent only in memory (channelSortSync.ts:113-121). destroy() deliberately cancels and discards it (:252-262), while a failed publish only logs and never retries (:167-210). A live remote also cancels the debounce while leaving pendingStore stranded (useChannelSortPreference.ts:82-103).
Reproduction: change a sort mode and quit/switch communities within two seconds, or let one publish time out and remount. Bootstrap then whole-blob-replaces the local cache with the relay head (useChannelSortPreference.ts:108-122), visibly reverting the user's choice. Please carry the durable outbox, generation ownership, serialized retry, and pending-aware remote application used by sections over to sort, with lifecycle tests.
[P2] Future relay heads can wedge stars, mutes, and sort publishing
Sections clamps created_at inside the relay's future-drift window (channelSectionsSync.ts:463-472), but stars (channelStarsSync.ts:298-301), mutes (matching code), and sort (channelSortSync.ts:183-186) stamp lastRemoteCreatedAt + 1 without a cap. If a self-authored head was accepted near the relay's +900s boundary, a correctly clocked second device emits +901s; the relay rejects it, and retries continue deriving from the same head until wall time catches up. Apply the same bounded timestamp rule across all four sidebar sync surfaces.
CI is green at this head. I did not duplicate the CI-equivalent suite locally; these failures are source-reproduced interleavings absent from the current tests.
0d797b5 to
16aeab6
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Re-reviewed exact current head 16aeab68b094baaba508a0f2fc91c06b0c430c1c against base ef0d2025683869418e8eee22ac5b5ac16c5198b7 after the force-rebase. Changes are still required.
All 19 files in this PR have the same Git blob IDs as at previously reviewed head 0d797b550e405eea9557e053f884533ad3dd891a, including all production sync files and tests. The only sidebar differences between the old and new repository trees are three UI files inherited from the new base; they do not touch the NIP-78 sync producers, consumers, or their direct dependencies. Consequently, the previous review's blockers survive unchanged:
- P1: sections/stars/mutes still lack cross-window ownership for the shared durable outboxes. Manager-local generation fencing cannot stop one desktop window from overwriting another window's persisted payload or from unconditionally clearing the newer payload after its own older ACK/no-op/adopt completion. The existing tests remain single-manager and do not cover two windows sharing the key.
- P1: sort still drops pending intent. It still has only an in-memory
pendingStore, no durable outbox or failure retry,destroy()still cancels and nulls the edit, and a live remote can still cancel the timer while leaving the intent stranded. - P2: stars, mutes, and sort still derive
created_at = max(now, lastRemoteCreatedAt + 1)without the future-drift clamp used by sections. A self-authored head accepted near the relay's +900s limit can therefore wedge subsequent writes until wall time catches up.
Please address the concrete reproductions and repair boundaries in the review on 0d797b550: #6525 (review)
Current CI is not green: Desktop Smoke E2E (2) failed at this head while several desktop jobs remain in progress. I am not using that still-unclassified failure as a separate code finding; the source blockers above independently require changes.
Carl's re-review found three edit-loss paths remaining after the stars/mutes rev-merge landed. [P2] Stars, mutes, and sort stamped createdAt = lastRemoteCreatedAt + 1 uncapped, so a self-authored head accepted near the relay's +900s drift boundary wedged every later publish until wall time caught up. Extract the sections clamp into a shared clampPublishCreatedAt in sidebarSyncWatermark.ts (all four surfaces already import that module) and wire sections/stars/mutes/sort to it. [P1] Sort preferences never got the durable lane: doPublish only logged on failure, destroy() discarded the pending edit, and a live remote cancelled the debounce with the edit stranded. Port the sections lane — durable outbox + bootstrap resume, generation/CAS ownership, single- flight + completion re-drive, 2s->30s backoff, 60s reconcile loop, and pending-aware remote application. Sort stays whole-blob LWW; a lost head is adopted at pre-publish rather than republished. Tests: a clamp test per surface, and sort lifecycle coverage — outbox resume after destroy-inside-debounce, retry without a later edit, overlapping-generation safety, live-remote-during-debounce adopt, and a hook-level pending-defer test. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sections, stars, mutes, and sort each persist an unpublished edit under one
localStorage outbox key per identity+relay, but generation ownership is only
in-memory per window. Without cross-window ownership, one window's completing
publish could clear a peer window's still-unpublished edit, and (on the merge
lanes) a peer's write could overwrite an edit before it published.
Every outbox write now mints an ownership token and stores a {store, token}
envelope; a completing publish compare-and-clears only when the stored token
still matches its own, so a peer's newer write survives an older window's ACK.
Stars and mutes additionally read-merge-write both the durable outbox and the
main store via their per-entry mergeStores, so two windows editing different
channels both survive; sort and sections replace whole-blob with LWW resolution
matching the relay. Sort also gains the durable outbox + bounded retry it
previously lacked, and stars/mutes/sort now clamp publish created_at inside the
relay's future-drift window like sections. The envelope reader tolerates a
legacy token-less entry so an outbox written by a prior build still resumes.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at exact head 9141fe292268e2681e65664bbeb708b732af2535.
[P1] The multi-window outbox operations are still racy
The new ownership token does not make the localStorage operations atomic. clearOutboxEntry reads and validates the stored token, then calls removeItem separately (sidebarSyncWatermark.ts:141-162). Window A can read token A, window B can write its newer {store, tokenB} envelope, and A can then remove B's value using the stale read. That still loses an unpublished edit on quit/remount.
Stars and mutes have the matching write-side race: writeChannelStarsOutbox reads the shared entry, merges in memory, then writes separately (channelStarsStorage.ts:226-237; mutes mirrors it). If two windows read before either writes, each computes a one-sided merge and the later setItem drops the other pending click. The main-store read/merge/write has the same shape (channelStarsStorage.ts:135-144).
The added multi-window tests execute whole operations sequentially, such as A write, B write, then A clear (multiWindowOutbox.test.mjs:62-167), so they cannot exercise either read/write or read/remove interleaving. localStorage provides no compare-and-delete or transactional read-modify-write primitive; the token proves what was read, not what is still stored at the destructive operation.
Please move this durability boundary to a design that does not depend on atomicity localStorage lacks, such as per-operation append-only records with owner-specific deletion, or an appropriately serialized cross-window store. Add tests that pause operations between their read and write/remove steps and verify teardown/remount preserves every unpublished intent.
The LWW comparator itself matches the relay's created_at DESC, id ASC rule; this review is blocking only on the durability race above. I reviewed read-only GitHub metadata and diff and did not check out or execute PR code.
localStorage has no atomic compare-and-delete or transactional read-modify-write, so a single outbox key shared across every window could not be mutated safely: one window's read-then-write or read-then-remove races a peer's write in the gap and drops its still-unpublished edit. A per-write ownership token narrowed that window but could not close it — the token proves what was read, not what is still stored at the destructive op. Key the outbox per window instead: <prefix>:<pubkey>:<relay>:<nonce>, where the nonce is minted once and parked in sessionStorage. Each window is the sole writer of its own key, so a hot-path write is one unconditional setItem — the write race is designed out, not guarded. Resume enumerates every window's key: merge lanes (stars/mutes) fold all records order-independently; whole-blob lanes (sort/sections) replay the max-queuedAt record with a nonce tiebreak. Redundant foreign keys are reclaimed at boot, gated on durable relay evidence (merge: head subsumes; whole-blob: head created_at supersedes) and re-read immediately before removal so a live peer's fresh write in the recheck gap survives. Reclamation runs only on a successful head fetch, never on a failed one. The token contract is deleted entirely — ownership is the key. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ce-free The per-window token/recheck reclamation still performed a non-atomic compare-then-delete on a mutable foreign key: a live owner could rewrite its key between the reclaim decision-read and the removeItem, and the whole-blob `queuedAt <= head.created_at` gate dropped same-second and legacy `queuedAt=0` records that had not provably lost LWW. Records are now write-once: a key is `<prefix>:<pubkey>:<relay>:<nonce>:<seq>` and is never rewritten. A new edit writes a new key (next zero-padded seq) then deletes its own older keys (write-before-delete, so a crash leaves at least one record). Foreign reclamation reads an immutable record, proves it reclaimable against durable relay evidence, and deletes it with no recheck. Whole-blob supersession is strict (`queuedAt < head.created_at`); replay runs before reclamation in every hook so a same-second record is consumed into pending first; the legacy v1 shared key is only ever replayed, never deleted. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The legacy v1 shared outbox key is never deleted (it is mutable and a concurrently-live old build may still rewrite it), so the whole-blob resume path re-read it on every boot and republished the stale blob above the current relay head forever. A found relay head never stopped it: a fresh manager has no lastPublishedStore and queueing the replay freezes publishBaseline to the just-fetched head. Distinguish compatibility replay from permanently pending intent with a durable per-value consumption marker, whole-blob lanes only. resumeWholeBlobOutbox excludes the legacy record when its exact raw matches the stored marker; a live old build rewriting the key stores a different raw and is replayed again. The hook transfers the intent into its own v2 key (synchronous publish) BEFORE writing the marker, so a crash in that gap replays the blob once more rather than losing it. Merge lanes (stars/mutes) need no marker but gained a head-subsumed gate so a lingering legacy key does not re-drive an identical boot-time publish. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at exact head 3712e6fb2c71f03587a476c36081b1ac831a60c7 against base ef0d2025683869418e8eee22ac5b5ac16c5198b7.
Product direction: the relay is authoritative, while sidebar sections/sort use whole-blob LWW and stars/mutes use per-entry max-merge. The compatibility contract is that all four lanes converge across concurrently running builds/windows without dropping an unpublished user edit. I reviewed the four surfaces across bootstrap, live delivery, storage events, reconnect, periodic/visibility reconciliation, local edits, publish failure/ACK, destroy, remount, and community switch. I found these blockers:
[P1] Preserve the original whole-blob baseline when reconnect wakes a pending edit
useChannelSections.ts:264-276 and useChannelSortPreference.ts:257-269 fetch on reconnect and then call the public publish*() method for an already-pending edit. Those methods increment pendingGeneration and reset publishBaseline to the now-current lastRemoteHead (channelSectionsSync.ts:277-295, channelSortSync.ts:273-289).
Reproduction: window A queues an edit against H0; window B publishes H1 before A reconnects. A's reconnect fetch records H1, and applyRemote correctly defers because A is pending. The reconnect handler then re-queues A's old store, replacing its frozen H0 baseline with H1. The pre-publish fetch sees equality rather than remoteAdvancedSince, so A publishes above H1 instead of adopting the remote winner. This reverses the intended LWW decision on both whole-blob lanes.
Wake/retry the existing generation without resetting its baseline, and cover remote advancement plus reconnect while an edit is pending for both sections and sort.
[P1] Do not clear a merge-lane outbox merely because its EVENT received OK
channelStarsSync.ts:279-330 and channelMutesSync.ts:279-330 clear their own durable outbox after publishEvent resolves. But the relay returns accepted OK for a superseded NIP-33 write as a no-op, and two windows can also prefetch the same head and publish different whole blobs at the same second. Only one blob is retained.
The shared cache does not close this race: writeChannelStarsStore / writeChannelMutesStore at storage lines 140-151 overwrite the shared key without reading it, while the storage handlers at useChannelStars.ts:67-74 and useChannelMutes.ts:67-74 ignore e.newValue and reread whichever snapshot currently occupies that key. Two windows can therefore create different-channel snapshots from the same base, overwrite the cache before either storage event is processed, and independently receive successful OKs. The non-retained window then deletes the only durable record of its click. The relay, cache, and all outboxes can end with that edit absent.
After publication, clear an own outbox only after an authoritative retained-head fetch proves that head subsumes the attempted store; otherwise merge and retry. Add a two-window test where distinct edits race from the same head and both OK paths complete in either NIP-33 order.
[P1] A later old-build click can lose forever to an earlier new-build rev in the same second
channelStarsStorage.ts:59-77,188-195 and the identical mutes code normalize a missing rev from an old build to 0, then rank updatedAt before rev. If a new build publishes {updatedAt:T, rev:1, starred:true}, an old build observes it and the user unstars during the same second T, that later old-build payload has no rev and parses as rev:0. New builds deterministically retain the earlier true/rev:1; the user's later action cannot win until another click occurs in a later second.
Keeping payload version: 1 allows parsing but does not make the causal model backward-compatible. Use an explicit migration/version strategy that handles old writers, or define and test a product-accepted mixed-fleet limitation rather than claiming observed later intent cannot be lost.
[P1] An unreadable current head must block whole-blob overwrite, not fall through to publish
channelSectionsSync.ts:353-359 and channelSortSync.ts:341-347 record an existing head, but if decryption or parsing fails they return publish. A pending local blob is then signed at lastRemoteCreatedAt + 1 and replaces the only current head, even though the client could not inspect it or determine whether it should be adopted. A future schema, transient keychain/decrypt fault, or malformed payload therefore becomes destructive data loss on the next local edit.
Treat an unreadable/future head as a failed precondition: retain the durable pending edit and retry/surface the incompatibility, but do not overwrite unknown authoritative state. Add pre-publish coverage for decryption failure and unsupported payload version in sections and sort.
…sidual Close the pass-3 review blocker and Carl's four P1s on the channel sections/sort/stars/mutes relay sync, all in the sidebar lib: - Gate the legacy-consumed marker on a proven v2 transfer. writeOwnOutbox now returns whether the fresh key's setItem succeeded; the whole-blob hooks write the marker only when durable, so a quota failure leaves the legacy record replayable instead of silently suppressing the only copy. - Reconnect wakes the existing pending edit (retryPendingPublish) rather than re-queueing via publish*(), which reset the frozen baseline and published a stale edit over an advanced remote. - Merge lanes clear an own outbox only after an authoritative retained-head fetch proves the head subsumes the attempted store; a bare EVENT OK on a superseded NIP-33 write no longer drops the loser's durable click. - An unreadable/unsupported pre-publish head retains the pending edit and retries rather than overwriting state the client could not inspect. - Document the accepted mixed-fleet residual (a same-second old-build click reads rev 0 and loses to an earlier new-build rev, healing on the next later-second click) and pin it with a test; no protocol change. Regressions added for each item, mutation-verified where the fix lives in the hook. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
REQUEST CHANGES on exact head 6078943f3be3a3fea99e86d225b79246af53f599 against ef0d2025683869418e8eee22ac5b5ac16c5198b7. I reviewed GitHub metadata, diff, and exact-head source only; I did not execute PR code.
Product contract: the relay is authoritative. Sections/sort converge as whole-blob LWW; stars/mutes converge by per-entry max-merge. A client must not overwrite relay state it could not read, because that turns a transient fetch/decrypt failure into durable cross-device data loss.
P1 — Retain edits when the authoritative pre-publish read fails
All four lanes fall through to publishing local state when the pre-publish relay query throws. Sections does so at channelSectionsSync.ts:371-416 and sort has the matching branch; stars does so at channelStarsSync.ts:210-232 and mutes mirrors it. The client therefore cannot distinguish “no head exists” from timeout/auth/socket failure.
Reproduction: device B publishes a newer sections/sort blob or independent star/mute entries after device A’s last observation; A edits while its pre-publish fetch fails. A signs above its stale watermark and can replace the unseen authoritative head, erasing B’s data. Durable outboxes and retries do not recover content that A never merged.
Return a tri-state publish decision in every lane: only a successful, genuinely absent head may publish the local store directly; fetch failure must retain the outbox and retry. Add causal tests for an unseen newer head plus rejected pre-publish fetch across sections, sort, stars, and mutes.
P1 — Stars and mutes also overwrite an existing but unreadable head
channelStarsSync.ts:220-231 records an event but returns the local store when decryption, JSON parsing, or schema validation fails; doPublish then publishes it (:315-357). Mutes has the identical path. This can destroy entries in a temporarily undecryptable or future-schema head. Bootstrap already classifies this condition as failed, while sections/sort correctly return retain for it.
Give merge-lane pre-publish reads the same retain/retry behavior and cover decrypt failure, malformed JSON, and unsupported schema. A max-merge is safe only after both operands were actually read. Dungeon law is unhelpfully strict about this.
P2 — Reject numeric revisions that cannot advance
channelStarsStorage.ts:59-69 and the matching mutes parser accept any finite non-negative integer rev, including values beyond Number.MAX_SAFE_INTEGER. Local clicks mint maxRev + 1 (useChannelStars.ts:252-267, mirrored for mutes); at sufficiently large IEEE-754 values that no longer increases. With equal timestamps/revisions, true wins (channelStarsStorage.ts:199-207), so a malformed starred:true or muted:true entry can suppress later unstar/unmute attempts indefinitely.
Require safe, bounded integers for revision and timestamp inputs, define malformed-input handling, and add a regression proving a huge revision cannot wedge a later false toggle.
The prior blockers around multi-window ownership, reconnect baselines, unreadable whole-blob heads, publish retention confirmation, and timestamp clamping are materially addressed. Exact-head CI is broadly green; it does not cover the failed-read transitions above.
Extends the retain-on-unreadable pattern to the two remaining overwrite paths and hardens the rev/timestamp parse boundary. - All four lanes tri-state the pre-publish read: a THROWN fetch (timeout / auth / socket) now retains and retries instead of falling through to publish, so an edit during a transient outage can no longer sign above a stale watermark and erase an unseen newer head. - The merge lanes (stars/mutes) retain when the existing head fails decryption/JSON/schema parsing rather than publishing the local store over it — a max-merge is only safe once both operands were read, matching the whole-blob lanes. - The rev/timestamp parsers require Number.isSafeInteger: an unsafe rev is normalized to 0 and an unsafe timestamp entry is dropped, so a malformed huge-rev true entry can no longer wedge later false toggles forever. Retain reuses the existing bounded-backoff retry and generation CAS, so a later readable/successful head resumes normal resolution. Regressions cover all four lanes for the failed-fetch and unreadable-head paths and the rev-wedge P2 case in both storage suites. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…adroom The rev parse bound used Number.isSafeInteger, which accepts Number.MAX_SAFE_INTEGER itself. The click path mints rev = maxRev + 1, so an accepted boundary rev overflows to an unsafe value that never advances again — recreating the same-second toggle wedge the bound exists to prevent. Reject rev >= Number.MAX_SAFE_INTEGER (exclusive, normalize to 0) so maxRev + 1 always stays safe. updatedAt keeps its inclusive bound: the click path takes Math.max(now, observed), never increments, so it cannot overflow. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
REQUEST CHANGES on exact head 8bbc11b340cc584975af8f44da0036e0b9bbddc3 against base ef0d2025683869418e8eee22ac5b5ac16c5198b7. I reviewed GitHub metadata, diff, exact-head source, and the relay replacement path only; I did not execute PR code.
Product contract: the relay is authoritative within its community. Sections/sort converge as whole-blob LWW; stars/mutes converge by per-entry max-merge. Local caches and accepted publish responses must not fabricate a retained head or carry one community's preferences into another.
P1 — Confirm retention before committing sections/sort publishes
Both whole-blob managers treat any resolved publishEvent as proof their event became the retained head: sections unconditionally records the attempted tuple, clears ambiguousAttemptIds, folds it into publishBaseline, and discards the outbox at desktop/src/features/sidebar/lib/channelSectionsSync.ts:529-559; sort mirrors this at channelSortSync.ts:486-511.
That premise is false for parameterized replaceable events. At this exact head, the DB classifies a same-or-older canonical tuple as Superseded (crates/buzz-db/src/replaceable.rs:238-251), and the relay deliberately maps that to PersistResult::Duplicate rather than a rejection (crates/buzz-relay/src/handlers/command_executor.rs:161-171). The client therefore sees a successful publish promise even though its event was not retained.
Reproduction: two windows fetch head H, then both stamp H.created_at + 1; the lower event ID wins, while the higher-ID loser also receives success. The loser records its nonexistent event as the baseline and drops its durable edit. If that user edits again before reconciliation, the pre-publish fetch returns the actual same-second lower-ID winner; remoteAdvancedSince() treats it as a new competing head, and adoptRemote() discards the second edit too. One collision can erase both the raced edit and the following user action.
After ACK, fetch the authoritative retained head. Only clear/fold the attempt when that exact ID is retained; otherwise keep/retry the durable intent or adopt the true winner without poisoning the next edit's baseline. Add a two-manager same-second collision test where the losing manager makes another edit.
P1 — Scope the stars/mutes primary cache to the relay
The new outboxes are relay-scoped, but the main stars and mutes stores remain pubkey-only: channelStarsStorage.ts:32-34,91-101,147-155 and the matching channelMutesStorage.ts use buzz-channel-…:<pubkey>. Their hooks reload that same cache after every relayUrl change (useChannelStars.ts:46-57,103-107; mutes mirrors it).
Reproduction: use relay A and persist non-empty stars/mutes, then switch the same identity to relay B for the first time. B's bootstrap successfully finds no head, its B-scoped watermark is zero, and runBootstrap seed-publishes the non-empty “local” store (sidebarSyncWatermark.ts:609-615). Those entries came from A, so A's community preferences are copied into B. Sections and sort already avoid this by including normalized relayUrl in their primary cache keys.
Scope every stars/mutes primary-cache read, write, and storage-event key by normalized relay. Define a deliberate one-time policy for the legacy pubkey-only cache rather than importing it into every newly visited relay, and add an A→B absent-head regression proving no seed leak.
Exact-head CI is broadly green; the Codex security review was cancelled. These source-reproduced state transitions are not covered by that result.
…utes Carl r6 raised two P1 correctness gaps. Whole-blob lanes (sections, sort) treated a resolved publishEvent as proof of retention, but the relay OKs a superseded NIP-33 write as a no-op (Duplicate). Two windows stamping the same created_at both get OK while only the lower event id is retained, so the loser recorded a nonexistent head as its baseline and its next edit adopted the true winner away. After ACK the lanes now fetch the authoritative head and only clear/fold/discard on an exact event-id match; a different readable head is adopted, an unprovable/unreadable/own-prior head is retained and retried. The stars/mutes primary cache was pubkey-only while its outbox was relay-scoped, so a first visit to relay B seed-published relay A's preferences. The cache key is now relay-scoped like sections/sort, with a one-time legacy migration that deletes the pubkey-only key so no other relay can import it. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…s gone Thufir's r6 review found the legacy→scoped migration exposed legacy data to bootstrap even when the scoped write threw or the legacy delete did not take, wrapped in a swallowed try/catch. On any storage failure the legacy key survived while the relay still received the legacy preferences and could seed-publish them; a second relay later read the same legacy key and seeded it too — so "globally one-time" held only on the happy path. migrateLegacyStore now exposes migrated data only when ownership is proven: write the scoped key, delete the legacy key, and confirm the delete took. On a scoped-write throw, a legacy-delete throw, or a delete that silently does not take, roll back the partial scoped copy and return DEFAULT_STORE, leaving the migration retryable. An empty store cannot seed-publish (bootstrap's non-empty-local gate), so DEFAULT is safe. The identical flaw existed in all three lanes with a legacy primary-cache migration — sections (pre-existing on main), stars, and mutes. Sort has no such migration and is untouched. Concurrent claimants are safe: every window resolves the same app-wide active community, so at any instant they scope to the same relay and any race migrates the identical value to the identical key; a second relay only enters play after a community switch, which is strictly after the first scoped read consumed the legacy key. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The migration wrote the scoped key before deleting the legacy key. If the legacy delete threw and the rollback delete also threw, the scoped copy survived and the next read gave it precedence, exposing legacy prefs to seed-publish while the legacy key was still importable by another relay. Move the ownership check into the reader: a scoped copy is exposed only while no importable legacy key remains, so a half-migrated copy can never be read as owned. The claim completes inline once storage recovers. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ly present The pass-3 catch rolled back the just-written scoped copy on any failure. If the legacy delete succeeded but the confirmation read then threw, legacy was already gone, so the rollback destroyed the only surviving copy — permanent preference loss. Roll back only when the legacy key is provably still importable (keeping the scoped copy then would let a second relay scope double-import it). If legacy is gone, the scoped copy is the sole copy and is kept; if the probe itself throws, keep it too, favoring no-data-loss. The read-time ownership gate keeps a kept-but-unproven copy hidden while legacy remains, so it never seeds early. Residual: legacy-delete and catch-probe both throw, then a relay switch after partial recovery can let both scopes carry the value — bounded and deliberately preferred over data loss. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes's GitHub account.
Changes requested
[P1] Preserve a newer edit when retained-head confirmation finds a same-second winner
Both whole-blob managers record the just-ACKed attempted tuple before awaiting authoritative confirmation (channelSectionsSync.ts:598-607, mirrored in channelSortSync.ts:555-564). A user edit queued during that await freezes the attempted tuple as its baseline (channelSectionsSync.ts:316-328, sort :312-322). If confirmation then returns a peer's lower-id same-second winner, adoptRemote records that winner but its generation guard leaves the newer edit's frozen baseline unchanged (channelSectionsSync.ts:294-301, sort :290-297). On the next cycle, remoteAdvancedSince compares winner {t,a} against attempted {t,z} and adopts the newer user edit away because a < z (channelSectionsSync.ts:414-427, sort :397-409). The outbox is cleared without ever publishing that edit.
This is reachable when a relay ACKs a superseded parameterized-replaceable write as a no-op, confirmation is delayed, and the user edits again before confirmation resolves. The collision tests queue the next edit only after confirmation/adoption has completed, so they do not exercise this interleaving.
Please fold a confirmed retained winner into the current generation's baseline when the newer edit was queued after the ACKed attempt became observable, while preserving the generation CAS for pending/UI state. Add deterministic sections and sort tests that pause confirmation, queue the second edit after ACK, then return the lower-id winner; the second edit must publish above that winner and remain durable until its own retention is confirmed.
The prior exact-head blockers around relay-scoped stars/mutes storage and non-overlapping retained-head confirmation appear addressed. This remaining race can still silently lose a sidebar sections or sort edit, so it is blocking.
… mid-confirmation When a second edit is queued after a publish ACK but before confirmRetainedHead resolves, pendingGeneration bumps while the first edit's confirmation fetch is in flight. The second edit's publishBaseline is frozen against the attempted (non-retained) event id. When confirmation returns the peer's same-second lower-id winner via adoptRemote with stale gen, the phantom baseline causes the second edit's pre-publish check to see the winner as having advanced past our attempt and adopt it away — the user's second edit is silently lost (Carl r6 P1). Extract foldSupersedingAttemptWinner (shared helper, both managers): when publishBaseline.eventId is one of our own attempted ids and the remote is the same-second lower-id peer winner, fold the winner into the baseline. The pre-publish check then sees equality and publishes above the true retained head. Scoped to same-second lower-id only — a strictly-later remote is a genuine advance that the pending edit must still adopt (pass-2 invariant). Invoke from adoptRemote stale-gen path (confirmation variant) and from fetchOwnBlobBeforePublish (confirmation-retain/retry variant). Both managers (sections, sort) mirrored. Add one regression per manager: pauses the confirmation fetch, queues the second edit during the pause, then releases with the peer winner. Asserts the second edit publishes above the winner and is confirmed retained rather than adopted away. Mutation: reverting the adoptRemote fold fails the test. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…dd regressions Track attempt id → generation in ambiguousAttemptIds (Map<string,number>) instead of a plain Set. foldSupersedingAttemptWinner now requires that the mapped generation is strictly less than pendingGeneration, making it explicit that the fold only applies when a newer edit's baseline was poisoned by an older generation's unconfirmed attempt — not when the current generation's own in-flight attempt is the baseline. The generation invariant is structurally guaranteed (publishBaseline is frozen before the attempt id is known), but the explicit guard provides defense in depth against future code changes and makes the constraint self-documenting. Add two regressions per lane (sections + sort): - Single-edit confirmation-retain path: ACK resolves, confirmation returns retain, retry sees peer winner → adopt, do not republish. Companion test proving correct adopt behavior in the no-second-edit path. - Queued-during-confirmation: second edit queued after ACK but before confirmRetainedHead resolves; stale-gen adoptRemote folds winner into baseline; second edit's pre-publish check publishes above true head. Mutation: reverting adoptRemote fold fails this test. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Channel sidebar state (sections, sort, stars, mutes) diverges between two clients on the same identity — e.g. a dev build and an installed DMG: sections differ at app open, sometimes self-heal after minutes, sometimes only after a manual "kick" edit. The causes are client-side gaps in the desktop sync managers. Client-only; no relay or database changes.
Two payload shapes, each converged with the model that fits it: sections and sort are whole-blob last-write-wins; stars and mutes are per-entry sets converged by max-merge. All four lanes share one publish-timestamp clamp (
clampPublishCreatedAt) so a skewed remote head can never wedge a lane's future publishes.Durable multi-window outbox (all four lanes)
Unpublished edits persist to
localStorageand survive quit or community switch inside the publish debounce.localStoragehas no atomic read-modify-write, so records are per-window and write-once (<prefix>:<pubkey>:<relay>:<nonce>:<seq>): a new edit writes a new key, then deletes the window's own older ones — no window can overwrite or stale-clear a peer's pending edit. Foreign records are reclaimed only against durable relay evidence (strictcreated_atsupersession for whole-blob lanes, head subsumption for merge lanes) and only after replay. The mutable legacy shared key from older builds is never deleted; whole-blob lanes replay it once per value via a consumption marker written only after the intent is durably transferred into a v2 key.Sections and sort (whole-blob LWW)
created_at DESC, id ASC).OKis not proof of retention (the relayOKs a superseded NIP-33 write as a no-op): afterOKthe lane fetches the authoritative head and clears/folds the baseline only on an exact event-id match; a different readable head is adopted, an unreadable or own-prior head retains and retries — so a same-second collision loser never records a phantom head that erases its next edit.destroy()and never retried failures).Stars and mutes (per-entry Lamport-rev max-merge)
rev(missing ⇒0; payload staysversion: 1so old builds keep parsing). One commutative, associative, idempotentmergeStores(updatedAt→rev→ true-leaf) runs on every observation path, replacing the previous ownership/dirty-set/supersession machinery.updatedAt = max(now, observed)and mintsrev = maxSeen + 1, so it dominates everything this build has observed for that channel.revis rejected at or aboveNumber.MAX_SAFE_INTEGER(exclusive, somaxSeen + 1always stays safe) andupdatedAtrequires a safe integer, so a malformed huge value can never wedge later toggles.(pubkey, relay)like sections and sort, so preferences from one community never bleed into another — in particular a non-empty store from relay A can no longer seed-publish onto a first-visited relay B. Legacy pubkey-only data migrates once on first scoped read; migrated data is exposed to publish only after the scoped write and legacy delete both succeed and the legacy key is confirmed gone — on a storage failure the read returns an empty store and rolls back the partial scoped copy only when the legacy key provably still holds importable data; if the legacy key is already gone (or unreadable) the scoped copy is preserved as the sole surviving copy, and a read-time gate keeps any unproven copy hidden until the legacy key is confirmed gone, leaving the migration retryable so no relay can import legacy prefs the legacy key still holds.OKis not proof of retention (a superseded NIP-33 write also getsOK): the outbox clears only once a fetched retained head subsumes what was written; otherwise merge and retry.Accepted residuals
Documented in-source; each is bounded and self-healing: a same-second old-build click loses to a new-build
rev ≥ 1until the next later-second click; an old build cannot reverse a future-stamped entry until its clock catches up; a >500-same-second-entry eviction window can deterministically lose one click; truly concurrent whole-blob edits resolve by LWW — the guarantee is that no window's publish erases another's unpublished intent, not a merge of concurrent blobs; at most one lingering legacy key and marker per lane per(pubkey, relay); and if both the legacy-key delete and the failure-path probe throw, the preserved scoped copy plus a later community switch after partial storage recovery can carry the legacy value into two relay scopes — preferred deliberately over the alternative of permanently losing the only surviving copy.